fix(skills): prevent path traversal in LocalSkillSource#1228
fix(skills): prevent path traversal in LocalSkillSource#1228adilburaksen wants to merge 1 commit into
Conversation
|
Hi @adilburaksen, thank you for your contribution! We appreciate you taking the time to submit this pull request. I noticed some formatting inconsistencies in the test cases. could you please address these issues and run the full Maven build without skipping the test cases to ensure everything is compliant? |
|
@adilburaksen, Thank you for your quick response. As per contribution policy, please ensure your PR consists of a single commit. Could you please change your commits accordingly? |
b0f2e30 to
8752425
Compare
|
Thanks for the review @hemasekhar-p. I've applied google-java-format to the test file so it matches the project style now, and I squashed everything down into a single commit as requested. I also ran the full build with tests enabled (no skips) and it passes, including LocalSkillSourceTest (16 tests, all green). Let me know if anything else is needed. |
|
Thank you for your update @adilburaksen, Currently this PR is under review by our team, we will keep you posted if any additional information is required. thank you. |
|
@sherryfox, Could you please review this. |
8752425 to
0cac4e9
Compare
|
Rebased onto current |
|
Quick follow-up: the Google OSS VRP report tracking this is now on hold pending an official merge, so a review here would really help close it out. The PR is ready from my side — single commit, CI green (CLA + check-changes), no conflicts with @sherryfox @hemasekhar-p — PTAL whenever you get a chance, happy to make any changes. |
|
Friendly follow-up on this one. CI is green (CLA + check-changes passing), it's a single clean commit scoped to Since my last note, the Google OSS VRP panel confirmed (2026-06-26) that they'll proceed with the reward evaluation immediately once this PR is merged — the report is otherwise ready and only gated on the upstream merge. @sherryfox @hemasekhar-p would you be able to take a look / import when you have a moment? Happy to rebase or adjust anything needed. Thanks! |
wikaaaaa
left a comment
There was a problem hiding this comment.
Thank you for the fix!
The approach looks great, I left some comments.
| } catch (SkillSourceException e) { | ||
| return Single.error(e); | ||
| } | ||
| Path skillDir = skillsBasePath.resolve(skillName); |
There was a problem hiding this comment.
Can we re-use the normalized path computed by validatePathWithBase? (here and in all other places its called)
We compute the normalized path, throw it away and resolve again, it would be better if we used the same path we validated.
|
|
||
| private static void validatePathWithinBase(Path base, String component) | ||
| throws SkillSourceException { | ||
| if (Path.of(component).isAbsolute()) { |
There was a problem hiding this comment.
wdyt about changing it to
if (base.getFileSystem().getPath(component).isAbsolute()) { ... }?
Path.of(component) always parses against FileSystems.getDefault(), but base.resolve(component) two lines down parses against base's filesystem. LocalSkillSource accepts an arbitrary Path skillsBasePath, which may come from a non-default provider.
| throws SkillSourceException { | ||
| if (Path.of(component).isAbsolute()) { | ||
| throw new SkillSourceException( | ||
| "Absolute paths are not allowed: " + component, SKILL_NOT_FOUND); |
There was a problem hiding this comment.
nit: the helper is used for validating both skill and resource paths, but throws SKILL_NOT_FOUND for both. RESOURCE_NOT_FOUND would be more consistent for the resource-path cases — though since it's a shared helper, this'd mean passing the error code in (or splitting it) so it can tell the two apart.
| Path resolved = base.resolve(component).normalize().toAbsolutePath(); | ||
| if (!resolved.startsWith(normalizedBase)) { | ||
| throw new SkillSourceException( | ||
| "Path traversal detected; component must remain within its parent directory: " |
There was a problem hiding this comment.
nit: "must remain within its parent directory" is imprecise — base is the containment root, which is the skills base dir for the skillName checks but the individual skill dir for the resource-path checks. Use a generic noun ("must not escape its base directory"), or if the helper becomes context-aware for the error code, say "skills base directory" vs "skill directory" accordingly. (And keep base out of the message to avoid leaking the absolute path.)
|
Thanks for the detailed review! Addressed all points in the latest push:
|
| throws SkillSourceException { | ||
| // Parse the component against the base's own filesystem: LocalSkillSource accepts an arbitrary | ||
| // skillsBasePath, which may come from a non-default provider, so Path.of (which always uses | ||
| // FileSystems.getDefault()) could disagree with base.resolve below. |
There was a problem hiding this comment.
I think we can skip this comment. Or not mention Path.of() since its not being used, mentioning it feels unnecessary.
| RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); | ||
| assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class); | ||
| assertThat(exception).hasCauseThat().hasMessageThat().contains("Path traversal detected"); | ||
| } |
There was a problem hiding this comment.
lets assert the error codes as well (in all test cases).
|
One of the checks is failing for this PR: "This pull request has 2 commits. |
LocalSkillSource resolved skill names and resource paths from caller input directly against skillsBasePath without validating containment, so a name or path containing ".." or an absolute path could escape the skills base directory (arbitrary read of files outside the configured skills root, e.g. credentials). Add validatePathWithinBase, which rejects absolute components and any component that normalizes outside its base directory, and route listResources, findResourcePath and findSkillMdPath through it. The helper returns the validated, normalized path so callers reuse it instead of re-resolving skillsBasePath.resolve(skillName). Component parsing uses base.getFileSystem() so it matches the filesystem base.resolve() uses when skillsBasePath comes from a non-default provider, and the error code is passed in so resource-path checks report RESOURCE_NOT_FOUND while skill-name checks report SKILL_NOT_FOUND. Add tests for skill-name traversal, resource-path traversal, findSkillMdPath (loadFrontmatter) traversal, and absolute-path rejection, each asserting on the specific error message.
9debf17 to
aac8aa0
Compare
|
Done — squashed into a single commit (content unchanged). Ready for another look. |
Summary
LocalSkillSourceresolves caller-suppliedskillNameandresourcePathstrings directly onto the file system without validating that the resulting
path remains within
skillsBasePath. A crafted value such as../../../etc/passwdpasses theFiles.exists()check (the OSstatcallfollows
..segments) and is returned as a valid resource path, enablingarbitrary file reads from the host.
Affected methods:
findResourcePath,listResources,findSkillMdPath.Fix
Added a private
validatePathWithinBase(Path base, String component)helperthat rejects:
/etc/passwd)base(e.g.
../../secret)The check mirrors the boundary enforcement already present in the Go
implementation (
filesystem_source.go).Tests
Five new test cases in
LocalSkillSourceTest:testLoadResource_pathTraversalInSkillName../other-dirSkillSourceException"Path traversal detected"testLoadResource_pathTraversalInResourcePath../../../etc/passwdSkillSourceException"Path traversal detected"testLoadResource_absoluteResourcePath/etc/passwdSkillSourceException"Absolute paths are not allowed"testListResources_pathTraversalInSkillName../../etcSkillSourceException"Path traversal detected"testListResources_pathTraversalInResourceDirectory../other-skillSkillSourceException"Path traversal detected"